/*
Copyright (c) 2008, Oleg S. Estekhin
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

    * Redistributions of source code must retain the above copyright notice,
      this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright notice,
      this list of conditions and the following disclaimer in the documentation
      and/or other materials provided with the distribution.
    * Neither the name of the copyright holders nor the names of its
      contributors may be used to endorse or promote products derived from this
      software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package ebml.io;

import java.io.Closeable;
import java.io.EOFException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;
import java.util.Deque;
import java.util.LinkedList;

import ebml.ElementType;
import ebml.VInteger;

/** The <code>EbmlReader</code> interface allows forward, read-only access to EBML data. */
public class EbmlReader implements Closeable {

    private final InputSource source;

    private final Deque<Element> containers;

    private Element container;

    private Element element;

    private byte[] sharedBuffer;


    /**
     * Creates a new EBML reader.
     *
     * @param source the source of bytes
     *
     * @throws NullPointerException if <code>source</code> is <code>null</code>
     */
    public EbmlReader(InputSource source) {
        this(source, Long.MAX_VALUE);
    }

    /**
     * Creates a new EBML reader.
     *
     * @param source the source of bytes
     * @param size the maximum number of bytes to read from the source
     *
     * @throws NullPointerException if <code>source</code> is <code>null</code>
     * @throws IllegalArgumentException if <code>size</code> is negative
     */
    public EbmlReader(InputSource source, long size) {
        if (source == null) {
            throw new NullPointerException("source is null");
        }
        if (size < 0L) {
            throw new IllegalArgumentException("size is negative");
        }
        this.source = source;
        containers = new LinkedList<Element>();
        container = new Element(null, size, ElementType.CONTAINER);
        element = Element.EMPTY;
    }


    @Override
    public void close() throws IOException {
        source.close();
        containers.clear();
        container = Element.EMPTY;
        element = Element.EMPTY;
    }


    private byte[] getSharedBuffer(int length) {
        if (sharedBuffer == null || sharedBuffer.length < length) {
            sharedBuffer = new byte[Math.max(2048, length)];
        }
        return sharedBuffer;
    }


    /**
     * Reads <code>length</code> bytes of data from the current element data into an array of bytes.
     *
     * @param buffer the buffer into which the data is read
     * @param offset the start offset in array <code>buffer</code> at which the data is written
     * @param length the number of bytes to read
     *
     * @throws NullPointerException if <code>buffer</code> is <code>null</code>
     * @throws IndexOutOfBoundsException if <code>offset</code> is negative, <code>length</code> is negative, or
     * <code>length</code> is greater than <code>buffer.length - offset</code>
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    private void readFully(byte[] buffer, int offset, int length) throws IOException {
        if (element.remaining < length) {
            throw new EOFException();
        }
        while (length > 0) {
            int r = source.read(buffer, offset, length);
            if (r < 0) {
                throw new EOFException();
            }
            offset += r;
            length -= r;
            element.remaining -= r;
        }
    }

    /**
     * Skips <code>length</code> bytes from an input stream.
     *
     * @param length the number of bytes to skip
     *
     * @throws EOFException if the input source reaches the end before skipping all the bytes
     * @throws IOException if an I/O error occurs
     */
    private void skipFully(long length) throws IOException {
        if (element.remaining < length) {
            throw new EOFException();
        }
        long skipped = 1;
        while (length > 0L && skipped > 0L) {
            skipped = source.skip(length);
            length -= skipped;
            element.remaining -= skipped;
        }
        while (length > 0L) {
            byte[] buffer = getSharedBuffer(2048);
            int r = source.read(buffer, 0, (int) Math.min(length, buffer.length));
            if (r < 0) {
                throw new EOFException();
            }
            length -= r;
            element.remaining -= r;
        }
    }


    /**
     * Reads the next child element of the current container and positions the stream at the beginning of the element
     * data.
     *
     * @return <code>true</code> if the child element is available; <code>false</code> otherwise
     *
     * @throws EbmlDataFormatException if the value of the element identifier or element data size read from the stream
     * is reserved
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    public boolean hasNext() throws IOException {
        skipFully(element.remaining);
        container.remaining -= element.sizeValue;
        element = container;
        VInteger identifier = readIdentifier();
        if (identifier == null) {
            element = Element.EMPTY;
            return false;
        } else {
            if (!identifier.isIdentifier() || identifier.isReserved()) {
                throw new EbmlDataFormatException("invalid element identifier value");
            }
            VInteger size = readSize();
            if (size.getValue() > container.remaining) {
                throw new EbmlDataFormatException("invalid element size value");
            }
            element = new Element(identifier, size, null);
            return true;
        }
    }


    /**
     * Returns the identifier of the current element.
     *
     * @return the element identifier
     *
     * @throws IllegalStateException if the current element is not available
     */
    public long getIdentifierValue() {
        if (element.identifier == null) {
            throw new IllegalStateException();
        }
        return element.identifierValue;
    }

    /**
     * Returns the identifier of the current element.
     *
     * @return the element identifier in the encoded form
     *
     * @throws IllegalStateException if the current element is not available
     */
    public VInteger getIdentifier() {
        if (element.identifier == null) {
            throw new IllegalStateException();
        }
        return element.identifier;
    }

    /**
     * Returns the data size of the current element.
     *
     * @return the element data size
     *
     * @throws IllegalStateException if the current element is not available
     */
    public long getSizeValue() {
        if (element.identifier == null) {
            throw new IllegalStateException();
        }
        return element.sizeValue;
    }

    /**
     * Returns the data size of the current element.
     *
     * @return the element data size in the encoded form
     *
     * @throws IllegalStateException if the current element is not available
     */
    public VInteger getSize() {
        if (element.identifier == null) {
            throw new IllegalStateException();
        }
        return element.size;
    }


    /**
     * Instructs the reader to parse the current element data as sub-elements. The current container will be saved on
     * the stack and the current element will become the new container.
     *
     * @throws IllegalStateException if the current element is not available or if the element data was already accessed
     * as some other type
     */
    public void enterContainer() {
        if (element.identifier == null || element.sizeValue != element.remaining || element.type != null) {
            throw new IllegalStateException();
        }
        containers.addFirst(container);
        container = element;
        container.type = ElementType.CONTAINER;
        element = Element.EMPTY;
    }

    /**
     * Instructs the reader to return to the previous container.
     *
     * @throws IllegalStateException if the current container represents the whole input source
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    public void leaveContainer() throws IOException {
        if (containers.isEmpty()) {
            throw new IllegalStateException();
        }
        skipFully(element.remaining);
        container.remaining -= element.sizeValue;
        element = container;
        container = containers.removeFirst();
    }


    /**
     * Reads the element data as a variable size integer.
     *
     * @return a variable size integer, or <code>null</code> if the end of the input source is reached
     *
     * @throws EbmlDataFormatException if the input source contains length descriptor with zero value
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    private VInteger readVariableIntegerUnsafe() throws IOException {
        if (element.remaining < 1L) {
            return null;
        }
        byte[] buffer = getSharedBuffer(8);
        int r = source.read(buffer, 0, 1);
        if (r < 1) {
            return null;
        }
        element.remaining--;
        int lengthDescriptor = buffer[0] & 0xff;
        if (lengthDescriptor == 0) {
            throw new EbmlDataFormatException("invalid length descriptor");
        }
        int mask = 0x80;
        int encodedSize = 1;
        while ((lengthDescriptor & mask) != mask) {
            mask >>= 1;
            encodedSize++;
        }
        readFully(buffer, 1, encodedSize - 1);
        long encodedValue = lengthDescriptor;
        for (int i = 1; i < encodedSize; i++) {
            encodedValue = encodedValue << 8 | buffer[i] & 0xff;
        }
        return VInteger.decode(encodedValue, encodedSize);
    }

    /**
     * Reads the element data as a signed integer.
     *
     * @param length the number of bytes to read
     *
     * @return the element data as a signed integer
     *
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    private long readSignedIntegerUnsafe(int length) throws IOException {
        byte[] buffer = getSharedBuffer(length);
        readFully(buffer, 0, length);
        long result = buffer[0]; // with sign extension
        for (int i = 1; i < length; i++) {
            result = result << 8 | buffer[i] & 0xff;
        }
        return result;
    }

    /**
     * Reads the element data as an unsigned integer.
     *
     * @param length the number of bytes to read
     *
     * @return the element data as an unsigned integer
     *
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    private long readUnsignedIntegerUnsafe(int length) throws IOException {
        byte[] buffer = getSharedBuffer(length);
        readFully(buffer, 0, length);
        long result = 0;
        for (int i = 0; i < length; i++) {
            result = result << 8 | buffer[i] & 0xff;
        }
        return result;
    }


    /**
     * Reads the identifier of the next child element from the current element data.
     *
     * @return the identifier of the child element, or <code>null</code> if the end of the element is reached
     *
     * @throws EbmlDataFormatException if the input source contains the reserved identifier value
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    private VInteger readIdentifier() throws IOException {
        return readVariableIntegerUnsafe();
    }

    /**
     * Reads the data size of the next child element from the current element data.
     *
     * @return the data size of the child element
     *
     * @throws EbmlDataFormatException if the input source contains the reserved data size value
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    private VInteger readSize() throws IOException {
        VInteger result = readVariableIntegerUnsafe();
        if (result == null) {
            throw new EOFException();
        }
        return result;
    }


    /**
     * Reads the element data as a signed integer.
     *
     * @return the element data as a signed integer
     *
     * @throws IllegalStateException if the current element is not available or if the element data was already accessed
     * as some other type
     * @throws EbmlDataFormatException if the element size is greater than <code>8</code>
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    public long readSignedInteger() throws IOException {
        if (element.identifier == null || element.sizeValue != element.remaining || element.type != null) {
            throw new IllegalStateException();
        }
        if (element.sizeValue > 8) {
            throw new EbmlDataFormatException("invalid signed integer size");
        }
        element.type = ElementType.SIGNED_INTEGER;
        int encodedValueSize = (int) element.sizeValue;
        if (encodedValueSize == 0) {
            return 0L;
        }
        return readSignedIntegerUnsafe(encodedValueSize);
    }

    /**
     * Reads the element data as an unsigned integer.
     *
     * @return the element data as an unsigned integer
     *
     * @throws IllegalStateException if the current element is not available or if the element data was already accessed
     * as some other type
     * @throws EbmlDataFormatException if the element size is greater than <code>8</code>
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    public long readUnsignedInteger() throws IOException {
        if (element.identifier == null || element.sizeValue != element.remaining || element.type != null) {
            throw new IllegalStateException();
        }
        if (element.sizeValue > 8) {
            throw new EbmlDataFormatException("invalid unsigned integer size");
        }
        element.type = ElementType.UNSIGNED_INTEGER;
        int encodedValueSize = (int) element.sizeValue;
        if (encodedValueSize == 0) {
            return 0L;
        }
        return readUnsignedIntegerUnsafe(encodedValueSize);
    }


    /**
     * Reads the element data as a floating-point number.
     *
     * If the element data size is equal to <code>4</code>, then an instance of the <code>Float</code> is returned. If
     * the element data size is equal to <code>8</code>, then an instance of the <code>Double</code> is returned.
     *
     * @return the element data as a floating-point number
     *
     * @throws IllegalStateException if the current element is not available or if the element data was already accessed
     * as some other type
     * @throws EbmlDataFormatException if the element size is not equal to <code>4</code> or <code>8</code>
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    public Number readFloat() throws IOException {
        if (element.identifier == null || element.sizeValue != element.remaining || element.type != null) {
            throw new IllegalStateException();
        }
        if (element.sizeValue != 4 && element.sizeValue != 8) {
            throw new EbmlDataFormatException("invalid float size");
        }
        element.type = ElementType.FLOAT;
        int encodedValueSize = (int) element.sizeValue;
        switch (encodedValueSize) {
            case 4:
                return Float.intBitsToFloat((int) readUnsignedIntegerUnsafe(4));
            case 8:
                return Double.longBitsToDouble(readUnsignedIntegerUnsafe(8));
            default:
                throw new AssertionError(encodedValueSize);
        }
    }


    /**
     * Reads the element data as a date.
     *
     * @return the element data as a date
     *
     * @throws IllegalStateException if the current element is not available or if the element data was already accessed
     * as some other type
     * @throws EbmlDataFormatException if the element size is not equal to <code>8</code>
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    public long readDate() throws IOException {
        if (element.identifier == null || element.sizeValue != element.remaining || element.type != null) {
            throw new IllegalStateException();
        }
        if (element.sizeValue != 8) {
            throw new EbmlDataFormatException("invalid date size");
        }
        element.type = ElementType.DATE;
        return readSignedIntegerUnsafe(8);
    }


    /**
     * Reads the element data as a string in the specified charset.
     *
     * @param charsetName the name of the charset to be used to decode the bytes
     *
     * @return the element data as a string
     *
     * @throws IllegalStateException if the current element is not available or if the element data was already accessed
     * as some other type
     * @throws java.nio.charset.CharacterCodingException if a a character encoding or decoding error occurs.
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    private String readString(String charsetName) throws IOException {
        if (element.identifier == null || element.sizeValue != element.remaining || element.type != null) {
            throw new IllegalStateException();
        }
        element.type = ElementType.ASCII_STRING; // or UTF_8_STRING, but for the housekeeping it does not matter
        int encodedValueSize = (int) element.sizeValue;
        if (encodedValueSize == 0) {
            return "";
        }
        byte[] buffer = getSharedBuffer(encodedValueSize);
        readFully(buffer, 0, encodedValueSize);
        while (encodedValueSize > 0 && buffer[encodedValueSize - 1] == 0) {
            encodedValueSize--;
        }
        CharsetDecoder decoder = Charset.forName(charsetName).newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT)
                .onUnmappableCharacter(CodingErrorAction.REPORT);
        return decoder.decode(ByteBuffer.wrap(buffer, 0, encodedValueSize)).toString();
    }

    /**
     * Reads the element data as an ASCII string.
     *
     * @return the element data as an ASCII string
     *
     * @throws IllegalStateException if the current element is not available or if the element data was already accessed
     * as some other type
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    public String readString() throws IOException {
        return readString("ASCII");
    }

    /**
     * Reads the element data as an UTF-8 string.
     *
     * @return the element data as an UTF-8 string
     *
     * @throws IllegalStateException if the current element is not available or if the element data was already accessed
     * as some other type
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    public String readUTF() throws IOException {
        return readString("UTF-8");
    }


    /**
     * Reads the element data as binary.
     *
     * @param buffer the buffer into which the data is read
     * @param offset the start offset in <code>buffer</code> at which the data is written
     * @param length the maximum number of bytes to read
     *
     * @return the actual number of bytes read, or <code>-1</code> if the end of the element data is reached
     *
     * @throws IllegalStateException if the current element is not available or if the element data was already accessed
     * as some other type
     * @throws NullPointerException if <code>buffer</code> is <code>null</code>
     * @throws IndexOutOfBoundsException if <code>offset</code> is negative, <code>length</code> is negative, or
     * <code>length</code> is greater than <code>buffer.length - offset</code>
     * @throws EOFException if the input source reaches the end before reading all the bytes
     * @throws IOException if an I/O error has occurred
     */
    public int readBinary(byte[] buffer, int offset, int length) throws IOException {
        if (element.identifier == null || element.type != null && element.type != ElementType.BINARY) {
            throw new IllegalStateException();
        }
        element.type = ElementType.BINARY;
        if (element.remaining == 0L) {
            return -1;
        }
        int r = source.read(buffer, offset, (int) Math.min(element.remaining, length));
        if (r < 0) {
            throw new EOFException();
        }
        element.remaining -= r;
        return r;
    }


    /** Contains the element frame information. */
    private static final class Element {

        private static final Element EMPTY = new Element(null, null, null);

        private final VInteger identifier;

        private final long identifierValue;

        private final VInteger size;

        private final long sizeValue;

        private long remaining;

        private ElementType type;

        private Element(VInteger identifier, long sizeValue, ElementType type) {
            this.identifier = identifier;
            identifierValue = identifier == null ? -1L : identifier.getEncodedValue();
            size = null;
            this.sizeValue = sizeValue;
            remaining = sizeValue;
            this.type = type;
        }

        private Element(VInteger identifier, VInteger size, ElementType type) {
            this.identifier = identifier;
            identifierValue = identifier == null ? -1L : identifier.getEncodedValue();
            this.size = size;
            sizeValue = size == null ? 0L : size.getValue();
            remaining = sizeValue;
            this.type = type;
        }

    }

}
